SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
7.6 KB · 172 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Confidence, EntityLinks, EventTypeBadge, entityHref, entityName, eventLabel } from '@/components/events/event-badge';5import { Note } from '@/components/stats/shared';6import { Container, PageHeader, Section } from '@/components/ui/section';7import { Unavailable } from '@/components/ui/unavailable';8import { api, ApiError } from '@/lib/api';9import { fmtAgo, fmtDateTime } from '@/lib/format';10import { routes, SITE_NAME, SITE_URL } from '@/lib/site';11import type { EventRow } from '@/lib/types';1213export const revalidate = 300;1415type Params = Promise<{ id: string }>;1617async function load(id: string): Promise<EventRow | null | 'unavailable'> {18  try {19    return (await api.event(id)).data;20  } catch (e) {21    if (e instanceof ApiError && e.notFound) return null;22    return 'unavailable';23  }24}2526export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {27  const { id } = await params;28  const ev = await load(id);29  const url = `${SITE_URL}/events/${encodeURIComponent(id)}`;30  if (!ev || ev === 'unavailable') return { title: 'Event', alternates: { canonical: url }, robots: { index: false } };31  const title = `${ev.title} — ${eventLabel(ev.type)}`;32  const description = ev.summary ? `${ev.summary.slice(0, 180)}${ev.summary.length > 180 ? '…' : ''}` : `${eventLabel(ev.type)} event detected ${fmtDateTime(ev.event_time)} (source: ${ev.source_name ?? ev.source_id ?? 'unknown'}).`;33  return {34    title,35    description,36    alternates: { canonical: url },37    openGraph: { title: `${title} | ${SITE_NAME}`, description, url, type: 'article', siteName: SITE_NAME, publishedTime: ev.event_time },38    twitter: { card: 'summary_large_image', title: `${title} | ${SITE_NAME}`, description },39  };40}4142function Field({ label, children, mono = false }: { label: string; children: React.ReactNode; mono?: boolean }) {43  return (44    <div className="border-t border-rule py-3">45      <p className="eyebrow">{label}</p>46      <div className={`mt-1 text-sm text-ink ${mono ? 'mono break-all' : ''}`}>{children}</div>47    </div>48  );49}5051export default async function EventDetailPage({ params }: { params: Params }) {52  const { id } = await params;53  const ev = await load(id);54  if (ev === null) notFound();55  if (ev === 'unavailable') {56    return (57      <Container>58        <PageHeader eyebrow="Event" title="Event" />59        <Unavailable what="Event detail" />60      </Container>61    );62  }63  const entities = ev.entities ?? [];64  const jsonLd = {65    '@context': 'https://schema.org',66    '@type': 'Event',67    name: ev.title,68    description: ev.summary ?? undefined,69    startDate: ev.event_time,70    eventStatus: 'https://schema.org/EventScheduled',71    location: { '@type': 'Place', name: 'Earth orbit' },72    url: `${SITE_URL}/events/${encodeURIComponent(ev.id)}`,73  };7475  return (76    <Container>77      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />78      <PageHeader79        eyebrow={80          <span className="inline-flex flex-wrap items-center gap-2">81            <Link href={routes.events()} className="hover:text-accent">Events</Link>82            <span aria-hidden>/</span>83            <EventTypeBadge type={ev.type} />84          </span>85        }86        title={ev.title}87        lede={ev.summary ?? undefined}88      >89        <p className="mono mt-4 text-xs text-ink-3">90          <time dateTime={ev.event_time}>{fmtDateTime(ev.event_time)}</time> · {fmtAgo(ev.event_time)}91          {ev.detected_at && <> · detected {fmtAgo(ev.detected_at)}</>}92        </p>93      </PageHeader>9495      <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_360px] lg:gap-14">96        <div className="space-y-10">97          <Section eyebrow="Entities" title={`${entities.length} linked ${entities.length === 1 ? 'entity' : 'entities'}`} className="pt-0 md:pt-0">98            {entities.length === 0 ? (99              <Unavailable what="Linked entities" compact />100            ) : (101              <table className="data-table stack text-sm">102                <thead>103                  <tr>104                    <th>Type</th>105                    <th>Entity</th>106                    <th>Relationship</th>107                    <th>NORAD</th>108                  </tr>109                </thead>110                <tbody>111                  {entities.map((e) => {112                    const href = entityHref(e);113                    return (114                      <tr key={`${e.type}-${e.id}`}>115                        <td data-label="Type" className="mono text-xs uppercase text-ink-3">{e.type.replace('_', ' ')}</td>116                        <td data-label="Entity" className="primary">117                          {href ? <Link href={href} className="link font-medium">{entityName(e)}</Link> : <span>{entityName(e)}</span>}118                          {e.type === 'launch' && e.name && <span className="ml-2 text-xs text-ink-3">{e.name}</span>}119                        </td>120                        <td data-label="Relationship" className="text-ink-2">{e.relationship ?? '—'}</td>121                        <td data-label="NORAD" className="mono text-xs">{e.norad_id ?? '—'}</td>122                      </tr>123                    );124                  })}125                </tbody>126              </table>127            )}128            <EntityLinks entities={entities} className="mt-3 md:hidden" max={4} />129          </Section>130131          <Section eyebrow="Metadata" title="Raw event metadata" className="pt-0 md:pt-0">132            {ev.metadata && Object.keys(ev.metadata).length ? (133              <pre className="mono scrollbar-thin overflow-x-auto rounded-md border border-rule bg-plane p-4 text-xs leading-relaxed text-ink-2">{JSON.stringify(ev.metadata, null, 2)}</pre>134            ) : (135              <Unavailable what="Metadata" compact />136            )}137            <Note className="mt-3">Metadata is stored exactly as produced by the connector that detected the event; keys vary by event type.</Note>138          </Section>139        </div>140141        <aside className="lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:self-start">142          <p className="eyebrow mb-1">Telemetry</p>143          <Field label="Event id" mono>{ev.id}</Field>144          <Field label="Type">145            <EventTypeBadge type={ev.type} size="md" />146            <span className="mono ml-2 text-xs text-ink-3">{ev.type}</span>147          </Field>148          <Field label="Event time" mono>{fmtDateTime(ev.event_time)}</Field>149          {ev.detected_at && <Field label="Detected at" mono>{fmtDateTime(ev.detected_at)}</Field>}150          <Field label="Source">151            {ev.source_url ? (152              <a href={ev.source_url} target="_blank" rel="noopener noreferrer" className="link">{ev.source_name ?? ev.source_id}</a>153            ) : (154              <span>{ev.source_name ?? ev.source_id ?? 'Unavailable'}</span>155            )}156            {ev.source_id && <span className="mono ml-2 text-xs text-ink-3">{ev.source_id}</span>}157          </Field>158          <Field label="Confidence">159            <Confidence value={ev.confidence} className="text-base" />160            <span className="ml-2 text-xs text-ink-3">161              derived · <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>162            </span>163          </Field>164          <div className="border-t border-rule pt-3">165            <Link href={routes.events(`type=${encodeURIComponent(ev.type)}`)} className="text-sm text-accent hover:underline">All {eventLabel(ev.type).toLowerCase()} events →</Link>166          </div>167        </aside>168      </div>169    </Container>170  );171}172